/*
 *  Copyright 2009 Coditate Software
 *  Licensed under the GNU Library General Public License (LGPL) 2.1 
 *  
 *  License available at: http://simplesavant.codeplex.com/license
 */
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Text;
using Coditate.Common.Util;
using Coditate.Savant.Formatters;

namespace Coditate.Savant.Core
{
    /// <summary>
    /// Provides common mapping operations with error checking and reporting.
    /// </summary>
    internal static class MappingUtils
    {
        public const int ChunkIndexLength = 3;
        public const int MaxChunks = 1000;
        public const int MinMaxAttributeLength = ChunkIndexLength + 1;

        /// <summary>
        /// Copies all mapped property values from an item object to a <see cref="PropertyValues"/> list.
        /// </summary>
        public static PropertyValues GetPropertyValues(ItemMapping mapping, object item)
        {
            object itemName = GetPropertyValue(item, mapping.ItemNameMapping);
            var itemNameStr = itemName as string;
            if (itemName == null || (itemNameStr != null && StringUtils.IsNullOrEmpty(itemNameStr, true)))
            {
                string message = string.Format("Item name of item '{0}' is null or an empty string.", item);
                throw new SavantDataException(message);
            }

            var values = new PropertyValues(itemName);
            foreach (AttributeMapping attributeMapping in mapping.AttributeMappings)
            {
                values[attributeMapping.PropertyName] = GetPropertyValue(item, attributeMapping);
            }

            return values;
        }

        public static void SetPropertyValue(object item, AttributeMapping mapping, object propertyValue)
        {
            PropertyInfo p = GetProperty(item.GetType(), mapping.PropertyName);
            try
            {
                p.SetValue(item, propertyValue, null);
            }
            catch (Exception ex)
            {
                string valueType = propertyValue != null ? propertyValue.GetType().FullName : "Unknown";
                string message =
                    string.Format(
                        "Error setting property '{0}'. The declared property type is '{1}'. The actual value type is '{2}'.",
                        mapping.FullPropertyName,
                        mapping.PropertyType,
                        valueType);
                throw new SavantDataException(message, ex);
            }
        }

        public static object GetPropertyValue(object item, AttributeMapping mapping)
        {
            PropertyInfo p = GetProperty(item.GetType(), mapping.PropertyName);
            try
            {
                return p.GetValue(item, null);
            }
            catch (Exception ex)
            {
                string message =
                    string.Format(
                        "Error reading property '{0}'. The property type is '{1}'.",
                        mapping.FullPropertyName,
                        mapping.PropertyType);
                throw new SavantDataException(message, ex);
            }
        }

        private static PropertyInfo GetProperty(Type t, string propertyName)
        {
            PropertyInfo p = t.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
            if (p == null || !p.CanRead && !p.CanWrite)
            {
                string message = string.Format("Item type '{0}' has no public, read/write property named '{1}'",
                                               t.FullName, propertyName);
                throw new SavantDataException(message);
            }
            return p;
        }

        /// <summary>
        /// Copies all mapped property values to the specified item.
        /// </summary>
        public static void SetPropertyValues(ItemMapping mapping, PropertyValues values, object item)
        {
            SetPropertyValue(item, mapping.ItemNameMapping, values.ItemName);

            foreach (string propertyName in values)
            {
                AttributeMapping attributeMapping = mapping[propertyName];
                if (attributeMapping == null)
                {
                    continue;
                }
                object value = values[attributeMapping.PropertyName];
                SetPropertyValue(item, attributeMapping, value);
            }
        }

        /// <summary>
        /// Converts an item name value to a string.
        /// </summary>
        public static string ItemNameToString(PropertyFormatter formatter, AttributeMapping attributeMapping,
                                              object itemName)
        {
            try
            {
                return PropertyValueToString(formatter, attributeMapping, itemName);
            }
            catch (Exception ex)
            {
                string message = string.Format("Item name of type '{0}' could not be converted to a string using the " +
                                               "formatter configured for item name property '{1}'.",
                                               itemName.GetType().FullName,
                                               attributeMapping.FullPropertyName);
                throw new SavantDataException(message, ex);
            }
        }

        /// <summary>
        /// Converts a property value to a string.
        /// </summary>
        public static string PropertyValueToString(PropertyFormatter formatter, AttributeMapping attributeMapping,
                                                   object propertyValue)
        {
            try
            {
                return formatter.ToString(attributeMapping.Formatter, propertyValue);
            }
            catch (Exception ex)
            {
                string message =
                    string.Format(
                        "Property '{0}' with value '{1}' could not be converted to a string. The property type is '{2}'.",
                        attributeMapping.FullPropertyName, propertyValue,
                        attributeMapping.PropertyType.FullName);
                throw new SavantDataException(message, ex);
            }
        }

        /// <summary>
        /// Converts an attribute string to a typed property value.
        /// </summary>
        public static object StringToPropertyValue(PropertyFormatter formatter, AttributeMapping attributeMapping,
                                                   string valueString)
        {
            try
            {
                return formatter.ToType(attributeMapping.Formatter, valueString,
                                        attributeMapping.FormatType);
            }
            catch (Exception ex)
            {
                string message =
                    string.Format(
                        "String value '{0}' could not be converted to expected property type '{1}' for property '{2}'.",
                        valueString, attributeMapping.PropertyType.FullName,
                        attributeMapping.FullPropertyName);
                throw new SavantDataException(message, ex);
            }
        }

        /// <summary>
        /// Joins into a single string a list of attribute values that have been split for storage in multiple SimpleDB attributes.
        /// </summary>
        public static string JoinAttributeValues(List<string> valueStrings, int maxAttributeLength, bool compress)
        {
            Arg.CheckInRange("valueStrings", valueStrings.Count, 1, MaxChunks);
            if (maxAttributeLength < MinMaxAttributeLength)
            {
                throw new SavantConfigurationException("SavantConfig.MaxAttributeLength may not be set to less than " +
                                                       MinMaxAttributeLength);
            }
            if (valueStrings.Where(s => s.Length < ChunkIndexLength).Any())
            {
                throw new SavantDataException(
                    "Unable to reassemble spanned attributes property. The data may have been corrupted.");
            }

            var buffer = new StringBuilder(valueStrings.Count*maxAttributeLength);
            var chunks =
                valueStrings.Select(
                    s => new {Index = s.Substring(0, ChunkIndexLength), Content = s.Substring(ChunkIndexLength)}).
                    OrderBy(
                    s => s.Index).ToList();
            foreach (var chunk in chunks)
            {
                buffer.Append(chunk.Content);
            }

            string value = buffer.ToString();

            if (compress)
            {
                byte[] bytes = Convert.FromBase64String(value);

                var ms = new MemoryStream(bytes);
                var zipStream = new GZipStream(ms, CompressionMode.Decompress, false);

                var reader = new StreamReader(zipStream, Encoding.UTF8);
                value = reader.ReadToEnd();
            }

            return value;
        }

        /// <summary>
        /// Splits a property value into a list of strings for storage in multiple SimpleDB attributes.
        /// </summary>
        public static List<string> SplitPropertyValue(string value, int maxAttributeLength, bool compress)
        {
            Arg.CheckNull("value", value);

            if (maxAttributeLength < MinMaxAttributeLength)
            {
                throw new SavantConfigurationException("SavantConfig.MaxAttributeLength may not be set to less than " +
                                                       MinMaxAttributeLength);
            }

            if (compress)
            {
                byte[] bytes = Encoding.UTF8.GetBytes(value);

                var ms = new MemoryStream((int) (bytes.Length*1.1));
                var zipStream = new GZipStream(ms, CompressionMode.Compress, false);
                zipStream.Write(bytes, 0, bytes.Length);
                zipStream.Flush();
                zipStream.Close();

                byte[] compressed = ms.ToArray();
                value = Convert.ToBase64String(compressed);
            }

            var chunks = new List<string>();
            int maxChunkLength = maxAttributeLength - ChunkIndexLength;
            int index = 0;
            do
            {
                int nextChunkLength = maxChunkLength;
                if (!compress)
                {
                    // compressed data is base64 encoded so 1 char always equals 1 byte when UTF-8 encoded by SimpleDB
                    nextChunkLength = CalculateNextChunkLength(value, index, maxChunkLength);
                }
                nextChunkLength = Math.Min(nextChunkLength, value.Length - index);

                string chunk = chunks.Count.ToString("000") + value.Substring(index, nextChunkLength);
                chunks.Add(chunk);
                index += nextChunkLength;
            } while (index < value.Length);

            if (chunks.Count > MaxChunks)
            {
                string message =
                    string.Format(
                        "Spanned property overflow. String property with length of {0} characters requires more than {1} attributes to store.",
                        value.Length, MaxChunks);
                throw new SavantDataException(message);
            }

            return chunks;
        }

        /// <summary>
        /// Calculates the number of characters of a string that can fit into 
        /// the specified number of bytes after being UTF-8 encoded.
        /// </summary>
        private static int CalculateNextChunkLength(string source, int startIndex, int maxBytes)
        {
            Encoding encoding = Encoding.UTF8;
            int currentIndex = startIndex;
            int byteCount = 0;
            int charCount = 0;
            var c = new char[1];
            while (byteCount < maxBytes && currentIndex < source.Length)
            {
                c[0] = source[currentIndex];
                byteCount += encoding.GetByteCount(c);
                if (byteCount <= maxBytes)
                {
                    charCount++;
                }
                currentIndex++;
            }

            return charCount;
        }

        /// <summary>
        /// Adds a property to the specified item.
        /// </summary>
        public static void AddProperty(AttributeMapping attributeMapping, PropertyValues values, object propertyValue)
        {
            if (attributeMapping.IsList)
            {
                AddListPropertyValue(attributeMapping, values, propertyValue);
            }
            else
            {
                values[attributeMapping.PropertyName] = propertyValue;
            }
        }

        /// <summary>
        /// Determines whether the specified value list is empty.
        /// </summary>
        public static bool IsEmptyList(ICollection valueList)
        {
            int count = 0;
            foreach (object o in valueList)
            {
                if (o != null)
                {
                    count++;
                }
            }
            return count == 0;
        }

        /// <summary>
        /// Converts a property value to a list of values.
        /// </summary>
        public static ICollection ToList(object value)
        {
            // casting directly to IEnumerable would treat strings as lists
            var valueList = value as ICollection;
            if (valueList == null || valueList.GetType().IsArray)
            {
                valueList = (value == null ? new object[] {} : new[] {value});
            }
            return valueList;
        }

        /// <summary>
        /// Adds a value to a list property
        /// </summary>
        public static void AddListPropertyValue(AttributeMapping attributeMapping, PropertyValues values, object propertyValue)
        {
            if (propertyValue == null)
            {
                return;
            }

            try
            {
                object list = values[attributeMapping.PropertyName];
                if (list == null)
                {
                    list = Activator.CreateInstance(attributeMapping.PropertyType);
                    values[attributeMapping.PropertyName] = list;
                }
                Type collectionInterface = attributeMapping.PropertyType.GetInterface("ICollection`1");
                MethodInfo addMethod = collectionInterface.GetMethod("Add");
                addMethod.Invoke(list, new[] {propertyValue});
            }
            catch (Exception ex)
            {
                string message =
                    string.Format(
                        "Error adding value to list property '{0}'. The property type is '{1}'. The value type is '{2}'.",
                        attributeMapping.FullPropertyName, attributeMapping.PropertyType,
                        propertyValue.GetType().FullName);
                throw new SavantDataException(message, ex);
            }
        }

        /// <summary>
        /// Creates an instance of an item type.
        /// </summary>
        public static object CreateInstance(Type itemType)
        {
            try
            {
                return Activator.CreateInstance(itemType);
            }
            catch (Exception ex)
            {
                string message =
                    string.Format(
                        "Unable to instantiate item of type '{0}'. Does the type have a public no-arg constructor?",
                        itemType.FullName);
                throw new SavantDataException(message, ex);
            }
        }
    }
}